Export backup on a background task with in-app progress#92
Merged
Conversation
Building the backup archive was driven by a ShareLink over a lazily-built Transferable, so tapping Export presented the share sheet immediately and left it in the system's blocking 'Preparing…' state while the (already off-main) encode + zip ran — reading as a freeze on a large database. Build the archive up-front on a background task instead, streaming progress to an in-app 'Exporting…' bar (mirroring import), then reveal a ShareLink to the ready file so the share sheet opens instantly. - BackupCoordinator.exportBackup gains an onProgress callback: the evidence-blob load drives the determinate leg (0…0.8, whole-percent throttled like import), then jumps to 1 once the opaque encode+zip writes the archive. - BackupModel.exportBackup marshals that progress onto backupProgress via the same AsyncStream + @mainactor observer pattern as importBackup. - SettingsView replaces the lazy ShareLink with an Export button (showing the progress label while exporting) plus a revealed 'Share backup' ShareLink over the ready URL; importProgressLabel is generalized to backupProgressLabel. - Remove the now-unused BackupArchiveFile; add exporting/share strings. Covered by BackupCoordinatorTests.exportReportsProgressUpToCompletion. Co-authored-by: Cursor <cursoragent@cursor.com>
- Remove the unreachable evidence.isEmpty guard in exportBackup's progress loop (the loop body only runs when evidence is non-empty, so count >= 1). - Reintroduce BackupArchiveFile as a non-lazy Transferable wrapping the ready archive URL, so ShareLink declares an explicit .zip content type instead of inferring it from the filename extension. Co-authored-by: Cursor <cursoragent@cursor.com>
A finished export left the 'Share backup' row (and its temp .zip) around indefinitely, so the link could go stale if the OS reclaimed the temp file. Auto-discard it after a retention window: a .task(id: exportedArchiveURL) sleeps 10 minutes, then hides the share row and deletes the temp file. The deletion routes through the coordinator, which owns the temp-file lifecycle, via a new BackupCoordinator.discardExport() (BackupModel.discardExport() forwards to it). Hiding before deleting closes the stale-tap window. Per the chosen direction this stays ShareLink-based, so hide-on-share-sheet- close isn't wired (ShareLink exposes no dismissal callback). Covered by BackupCoordinatorTests.discardExportDeletesTheExportDirectory. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Exporting a backup from Settings felt like a freeze on a large database. The export CPU/IO already ran off the main thread (
SwiftDataStoreis a@ModelActor; the zip is aTask.detached(priority: .utility)), so the freeze was really the system share sheet's modal "Preparing…" phase: the oldShareLinkused a lazily-builtTransferable, so tapping Export presented the share sheet immediately and left it blocking in "Preparing…" while the archive built.This builds the archive up-front on a background task, streaming progress to an in-app "Exporting…" bar (mirroring import), then reveals a
ShareLinkto the ready file so the share sheet opens instantly.Changes
BackupCoordinator.exportBackupgains anonProgresscallback: the evidence-blob load drives the determinate leg (0…0.8, whole-percent throttled like import), then jumps to1once the opaque encode + zip writes the archive.BackupModel.exportBackupmarshals that progress ontobackupProgressvia the sameAsyncStream+@MainActorobserver pattern asimportBackup.SettingsViewreplaces the lazyShareLinkwith an Export button (showing the progress label while exporting) plus a revealed "Share backup"ShareLinkover the ready file.importProgressLabelis generalized tobackupProgressLabel(_:systemImage:), reused by both rows.BackupArchiveFileis now a non-lazyTransferablewrapping the ready URL, so the.zipcontent type stays explicitly declared rather than inferred from the filename..task(id: exportedArchiveURL)hides the "Share backup" row and deletes the temp file after a retention window, so a stale link can't be shared. Deletion routes through the newBackupCoordinator.discardExport()(which owns the temp-file lifecycle);BackupModel.discardExport()forwards to it.settings.backup.exporting("Exporting…") andsettings.backup.share("Share backup") strings.Notes / trade-offs
0.8then completes — inherent to one opaque zip call, documented in code.ShareLink-based (one extra tap: Export → then Share) rather than aUIActivityViewControllerbridge. As a consequence, "hide when the share sheet closes" isn't wired —ShareLinkexposes no dismissal callback — so the temp file is reclaimed by the 10-minute expiry (or the next export) instead.Testing
BackupCoordinatorTests.exportReportsProgressUpToCompletion— progress is non-empty, in(0, 1], and ends at1.BackupCoordinatorTests.discardExportDeletesTheExportDirectory— the temp directory is gone afterdiscardExport, and a second call is an idempotent no-op.BackupModelTests/ backup round-trip tests still pass../swiftformat --lintclean;WhereCoreTestsandWhereUITestspass locally.Made with Cursor